HypothesisでProperty-Based Testingを始めてみた
はじめに
データ事業本部の藤川です。
開発プロジェクトにおいて必要不可欠なテストですが、ソフトウェアテストの7原則にあるように、全数テストを行うことは不可能です。より少ないケース数で、網羅性の高いテストを行うことが望ましいです。
今回は、Property-Based Testing(PBT; プロパティベーステスト)の概要とPythonにおけるプロパティベーステストの代表的なライブラリであるHypothesisについてご紹介します。
概要
PBTとEBT
Property-Based Testing
(以下、PBT
; プロパティベーステスト
)は、テスト対象のコードが満たすべきプロパティ(特性)を記述し、そのプロパティがどのような入力に対しても成立することを確認するテスト手法です。従来のExample-Based Testing
(以下、EBT
; 例示ベースのテスト
)が具体的な入力と期待値通りの出力結果を組み合わせて記述するのに対し、PBTではコードが持つべき一般的なプロパティを記述します。
EBTは、UnittestやPytestでPythonのテストコードを記述したことがあればお馴染みのテスト手法です。EBTには以下のようなデメリットがあります。
EBTは小規模なシステムや単純なロジックをテストする際には有効です。一方で、複雑なシステムや未知のエッジケースを考慮する必要がある場合は、他のテスト手法を併用し、補間できることが望ましいでしょう。
PBTは、EBTを置き換えるものではありません。PBT単独、あるいは、EBTとPBTを併用することで、以下のようなデメリットを改善できるものと考えます。
EBTにおけるデメリット
- エッジケースの網羅性に欠ける
- EBTでは具体的な例(入力と期待される出力のペア)を記述するため、記述されていないエッジケースがテストされない
- 開発者が考慮していない入力値や予測できない状況において、テストケースが漏れてしまうリスクがある
- 保守コストが高い
- コードや仕様が変更されるたびに、既存のテストケースを修正する必要がある
- 既存のテストケースを含めて、テストケースの網羅性を確認する必要がある
- テストの目的や意図が伝わりにくい
- テストケースの重複
- 開発者が異なる文脈で、同様のテストケースを記述してしまう恐れがある
- 新たなバグを見逃す可能性
- 開発者が予想した具体的なシナリオに基づいてテストを記述するため、想定外の入力値やシナリオで発生するバグを見逃す可能性がある
- 潜在的な問題を検出しにくい
- ランダムなデータや複雑な組み合わせによるバグを発見しにくい
- 再利用性の低さ
- テストケースが特定の入力値と出力値に特化したものであるため、他のシステムやユースケースに再利用するのが難しい
Hypothesisとは?
Hypothesis
とは、Pythonにおけるプロパティベーステストの代表的なライブラリです。プロパティのデータ型を考慮した、あらゆる入力値を与えてくれます。Hypothesis
はテストデータ発生器として振る舞うため、EBTツールのように、テストデータを作成するコードを開発者が記述する必要がありません。
したがって、テストコードの保守性が向上し、可読性も良くなります。開発者がランダムなテストデータの発生器をわざわざ開発する手間を軽減できます。文字列であれば、名前であっても、URLであっても、入力文字数や空の場合についてテストをするので、テストデータ発生器を再利用することも可能です。
- Most testing is ineffective - Hypothesis
- HypothesisWorks/hypothesis: Hypothesis is a powerful, flexible, and easy to use library for property-based testing.
使用方法
Hypothesis
の使用方法について見ていきます。
インストール
pip install hypothesis
どんなテストデータが作成される?
どのようなテストデータが生成されるか見てみましょう。
Hypothesisでは、strategies
というクラスが提供されています。
strategiesで各データ型のexample()
を実行すると、ランダムにテストデータを生成してくれます。
- 文字列型(
text()
)
python
>>> from hypothesis import strategies as st
>>> st.text().example()
''
>>> st.text().example()
'Ã\x93\U00049b4c\x86\x82\x16ü®ä\U00062c7e'
>>> st.text().example()
'\U001038b8'
>>> st.text().example()
'é\U0003fcffÏZÉ¿\x85j'
>>> st.text().example()
'§G\x05@\x82\U00064a75¦\U000bd4d8\U0005a5d3\U00064024Çc\U0010622b:'
>>> st.text().example()
'À\x11ê\x11\U0003239e'
>>> st.text().example()
'4\U000c622d'
>>> st.text().example()
'\U001046a5¦'
- 整数型(
integers()
)
python
>>> from hypothesis import strategies as st
>>> st.integers().example()
-85
>>> st.integers().example()
72
>>> st.integers().example()
-37
>>> st.integers().example()
24593
>>> st.integers().example()
-8002740004038626608
- 浮動小数点型(
floats()
)
python
>>> from hypothesis import strategies as st
>>> st.floats().example()
2.2250738585072014e-308
>>> st.floats().example()
0.0
>>> st.floats().example()
6.103515625e-05
>>> st.floats().example()
5e-324
>>> st.floats().example()
inf
>>> st.floats().example()
1.401298464324817e-45
Pytestで使用してみた
example()
を呼び出すと、テストデータが得られます。
Unittest、Pytestでテスト実行する場合は、@given()
を記述し、strategiesが生成するデータの型や範囲を指定するだけです。デフォルトではランダムに100個のテストデータが得られます。
from hypothesis import given, strategies as st
from pydantic import BaseModel, EmailStr, PaymentCardNumber, PositiveFloat
class Model(BaseModel):
card: PaymentCardNumber
price: PositiveFloat
users: list[EmailStr]
@given(st.builds(Model))
def test_property(instance):
# Hypothesis calls this test function many times with varied Models,
# so you can write a test that should pass given *any* instance.
assert 0 < instance.price
assert all('@' in email for email in instance.users)
@given(st.builds(Model, price=st.floats(100, 200)))
def test_with_discount(instance):
# This test shows how you can override specific fields,
# and let Hypothesis fill in any you don't care about.
assert 100 <= instance.price <= 200
hypothesis.strategiesについて
Hypothesisの中心的な機能はstrategies
です。strategiesを使用し、生成するデータの型や範囲を指定できます。主なデータ型とテストデータを発生させサンプルコードをまとめてみました。
他にも役立つstrategiesが提供されていますので、ドキュメントを探してみましょう。
メソッド名 | 説明 | サンプルコード |
---|---|---|
booleans() | 真偽値 (True または False) を生成 | from hypothesis.strategies import booleans example = booleans().example() print(example) |
integers() | 整数を生成 | from hypothesis.strategies import integers example = integers(min_value=0, max_value=100).example() print(example) |
floats() | 浮動小数点数を生成 | from hypothesis.strategies import floats example = floats(min_value=0.0, max_value=1.0).example() print(example) |
text() | Unicode文字列を生成 | from hypothesis.strategies import text example = text(min_size=1, max_size=10).example() print(example) |
characters() | 単一のUnicode文字を生成 | from hypothesis.strategies import characters example = characters().example() print(example) |
lists() | リストを生成 | from hypothesis.strategies import lists, integers example = lists(integers(), min_size=1, max_size=5).example() print(example) |
dictionaries() | 辞書を生成 | from hypothesis.strategies import dictionaries, text, integers example = dictionaries(keys=text(), values=integers()).example() print(example) |
sets() | セットを生成 | from hypothesis.strategies import sets, integers example = sets(integers(), min_size=1, max_size=5).example() print(example) |
tuples() | タプルを生成 | from hypothesis.strategies import tuples, text, integers example = tuples(text(), integers()).example() print(example) |
none() | None 値を生成 | from hypothesis.strategies import none example = none().example() print(example) |
just() | 特定の値を生成 | from hypothesis.strategies import just example = just("固定値").example() print(example) |
one_of() | 複数の戦略からランダムに選択 | from hypothesis.strategies import one_of, integers, text example = one_of(integers(), text()).example() print(example) |
sampled_from() | 指定したリストからランダムに選択 | from hypothesis.strategies import sampled_from example = sampled_from(["A", "B", "C"]).example() print(example) |
builds() | 関数やクラスのコンストラクタを利用 | from hypothesis.strategies import builds, text class MyClass: def init(self, name): self.name = name example = builds(MyClass, name=text()).example() print( example.name) |
composite() | カスタム戦略を作成 | from hypothesis.strategies import composite, integers, text @composite def custom_strategy(draw): return { 'id': draw(integers(min_value=1)), 'name': draw(text(min_size=1)) } example = custom_strategy().example() print(example) |
さいごに
冒頭でも申しましたように、全数テストは不可能です。効果的なテストケースを数多くテストした方が品質は良くなるでしょう。工数には限りがあるため、できる限り無駄な工数を掛けたくありません。車輪の再発明は以ての外です。ライブラリを活用しましょう。
また、PBTはEBTを置き換えるものではありません。開発者の肌感覚は大切です。既存のテストコードを補間するよう、PBTを取り入れて行くのが、プロジェクトの進め方として正しいでしょう。
今回は、本の触りだけHypothesisをご紹介しましたが、実践的な開発プロセスについてもご紹介したいと思います。